turbomcp_server/
error_sanitization.rs1use regex::Regex;
39use std::sync::OnceLock;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum DisplayMode {
44 #[default]
46 Production,
47 Development,
49}
50
51#[derive(Debug)]
53pub struct SanitizedError<E> {
54 error: E,
55 mode: DisplayMode,
56}
57
58impl<E> SanitizedError<E> {
59 pub fn new(error: E, mode: DisplayMode) -> Self {
61 Self { error, mode }
62 }
63
64 pub fn production(error: E) -> Self {
66 Self::new(error, DisplayMode::Production)
67 }
68
69 pub fn development(error: E) -> Self {
71 Self::new(error, DisplayMode::Development)
72 }
73
74 pub fn into_inner(self) -> E {
76 self.error
77 }
78
79 pub fn inner(&self) -> &E {
81 &self.error
82 }
83}
84
85impl<E: std::fmt::Display> std::fmt::Display for SanitizedError<E> {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self.mode {
88 DisplayMode::Development => write!(f, "{}", self.error),
89 DisplayMode::Production => {
90 let message = self.error.to_string();
91 write!(f, "{}", sanitize_error_message(&message))
92 }
93 }
94 }
95}
96
97impl<E: std::error::Error> std::error::Error for SanitizedError<E> {
98 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99 self.error.source()
100 }
101}
102
103pub fn sanitize_error_message(message: &str) -> String {
131 let mut sanitized = message.to_string();
132
133 sanitized = sanitize_connection_strings(&sanitized);
138
139 sanitized = sanitize_urls(&sanitized);
141
142 sanitized = sanitize_secrets(&sanitized);
144
145 sanitized = sanitize_ip_addresses(&sanitized);
147
148 sanitized = sanitize_file_paths(&sanitized);
150
151 sanitized = sanitize_email_addresses(&sanitized);
153
154 sanitized
155}
156
157fn sanitize_file_paths(message: &str) -> String {
159 static UNIX_PATH_RE: OnceLock<Regex> = OnceLock::new();
160 static WINDOWS_PATH_RE: OnceLock<Regex> = OnceLock::new();
161
162 let unix_re = UNIX_PATH_RE.get_or_init(|| Regex::new(r"(?:/|\./)[\w\-./]+(?:\.\w+)?").unwrap());
164
165 let windows_re = WINDOWS_PATH_RE
167 .get_or_init(|| Regex::new(r"(?:[A-Za-z]:\\|\\\\)[\w\-\\/.]+(?:\.\w+)?").unwrap());
168
169 let mut sanitized = unix_re.replace_all(message, "[PATH]").to_string();
170 sanitized = windows_re.replace_all(&sanitized, "[PATH]").to_string();
171
172 sanitized
173}
174
175fn sanitize_ip_addresses(message: &str) -> String {
177 static IPV4_RE: OnceLock<Regex> = OnceLock::new();
178 static IPV6_RE: OnceLock<Regex> = OnceLock::new();
179
180 let ipv4_re = IPV4_RE.get_or_init(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap());
182
183 let ipv6_re = IPV6_RE
185 .get_or_init(|| Regex::new(r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b").unwrap());
186
187 let mut sanitized = ipv4_re.replace_all(message, "[IP]").to_string();
188 sanitized = ipv6_re.replace_all(&sanitized, "[IP]").to_string();
189
190 sanitized
191}
192
193fn sanitize_connection_strings(message: &str) -> String {
195 static CONN_STRING_RE: OnceLock<Regex> = OnceLock::new();
196
197 let conn_re = CONN_STRING_RE.get_or_init(|| {
199 Regex::new(r"\b(?:postgres|mysql|mongodb|redis|amqp|kafka)://[^\s]+").unwrap()
200 });
201
202 conn_re.replace_all(message, "[CONNECTION]").to_string()
203}
204
205fn sanitize_secrets(message: &str) -> String {
207 static SECRET_RE: OnceLock<Regex> = OnceLock::new();
208
209 let secret_re = SECRET_RE.get_or_init(|| {
214 Regex::new(r"(?i)\b(api[_-]?key|token|password|secret|bearer)(\s*[=:]?\s*)([^\s,;)]+)")
215 .unwrap()
216 });
217
218 secret_re
220 .replace_all(message, |caps: ®ex::Captures| {
221 format!("{}=[REDACTED]", caps[1].to_lowercase())
222 })
223 .to_string()
224}
225
226fn sanitize_email_addresses(message: &str) -> String {
228 static EMAIL_RE: OnceLock<Regex> = OnceLock::new();
229
230 let email_re = EMAIL_RE.get_or_init(|| {
232 Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap()
233 });
234
235 email_re.replace_all(message, "[EMAIL]").to_string()
236}
237
238fn sanitize_urls(message: &str) -> String {
240 static URL_RE: OnceLock<Regex> = OnceLock::new();
241
242 let url_re = URL_RE.get_or_init(|| Regex::new(r"\b(?:https?|ftp)://[^\s]+").unwrap());
244
245 url_re.replace_all(message, "[URL]").to_string()
246}
247
248pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again or contact support.";
252
253pub fn generic_error() -> String {
255 GENERIC_ERROR_MESSAGE.to_string()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_sanitize_unix_paths() {
264 assert_eq!(
265 sanitize_file_paths("File not found: /etc/secrets/key.txt"),
266 "File not found: [PATH]"
267 );
268 assert_eq!(
269 sanitize_file_paths("Error reading ./config/database.yml"),
270 "Error reading [PATH]"
271 );
272 assert_eq!(
273 sanitize_file_paths("Failed: /home/user/.ssh/id_rsa"),
274 "Failed: [PATH]"
275 );
276 }
277
278 #[test]
279 fn test_sanitize_windows_paths() {
280 assert_eq!(
281 sanitize_file_paths("File not found: C:\\Windows\\System32\\config.sys"),
282 "File not found: [PATH]"
283 );
284 assert_eq!(
285 sanitize_file_paths("Error: \\\\server\\share\\data.txt"),
286 "Error: [PATH]"
287 );
288 }
289
290 #[test]
291 fn test_sanitize_ipv4_addresses() {
292 assert_eq!(
293 sanitize_ip_addresses("Connection to 192.168.1.100 failed"),
294 "Connection to [IP] failed"
295 );
296 assert_eq!(
297 sanitize_ip_addresses("Server: 10.0.0.1:8080"),
298 "Server: [IP]:8080"
299 );
300 }
301
302 #[test]
303 fn test_sanitize_ipv6_addresses() {
304 assert_eq!(
305 sanitize_ip_addresses("Failed: 2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
306 "Failed: [IP]"
307 );
308 }
309
310 #[test]
311 fn test_sanitize_connection_strings() {
312 assert_eq!(
313 sanitize_connection_strings("Connect failed: postgres://user:pass@localhost:5432/db"),
314 "Connect failed: [CONNECTION]"
315 );
316 assert_eq!(
317 sanitize_connection_strings("Error: mongodb://admin:secret@cluster.example.com/mydb"),
318 "Error: [CONNECTION]"
319 );
320 }
321
322 #[test]
323 fn test_sanitize_secrets() {
324 assert_eq!(
325 sanitize_secrets("API key: api_key=sk_test_1234567890abcdef"),
326 "API key: api_key=[REDACTED]"
327 );
328 assert_eq!(
329 sanitize_secrets("Auth failed: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
330 "Auth failed: token=[REDACTED]"
331 );
332 assert_eq!(
333 sanitize_secrets("Login: password=MySecretPass123"),
334 "Login: password=[REDACTED]"
335 );
336 assert_eq!(
337 sanitize_secrets("Header: Authorization: Bearer abc123"),
338 "Header: Authorization: bearer=[REDACTED]"
339 );
340 }
341
342 #[test]
343 fn test_sanitize_email_addresses() {
344 assert_eq!(
345 sanitize_email_addresses("User: admin@example.com"),
346 "User: [EMAIL]"
347 );
348 assert_eq!(
349 sanitize_email_addresses("Contact: support@company.org"),
350 "Contact: [EMAIL]"
351 );
352 }
353
354 #[test]
355 fn test_sanitize_urls() {
356 assert_eq!(
357 sanitize_urls("Failed to fetch: https://api.example.com/v1/users"),
358 "Failed to fetch: [URL]"
359 );
360 assert_eq!(
361 sanitize_urls("Error: http://internal-service.local/health"),
362 "Error: [URL]"
363 );
364 }
365
366 #[test]
367 fn test_full_sanitization() {
368 let message = "Connection to postgres://admin:pass@192.168.1.100:5432/db failed. \
369 Check /etc/database/config.yml and contact support@company.com. \
370 API key: api_key=sk_live_abc123";
371
372 let sanitized = sanitize_error_message(message);
373
374 assert!(!sanitized.contains("postgres://"));
376 assert!(!sanitized.contains("admin:pass"));
377 assert!(!sanitized.contains("192.168.1.100"));
378 assert!(!sanitized.contains("/etc/database"));
379 assert!(!sanitized.contains("support@company.com"));
380 assert!(!sanitized.contains("sk_live_abc123"));
381
382 assert!(sanitized.contains("[CONNECTION]"));
384 assert!(sanitized.contains("[PATH]"));
385 assert!(sanitized.contains("[EMAIL]"));
386 assert!(sanitized.contains("[REDACTED]"));
387 }
388
389 #[test]
390 fn test_sanitized_error_production_mode() {
391 let error = std::io::Error::new(
392 std::io::ErrorKind::NotFound,
393 "File not found: /etc/secrets/api_key.txt",
394 );
395
396 let sanitized = SanitizedError::production(error);
397 let display = format!("{}", sanitized);
398
399 assert!(!display.contains("/etc/secrets"));
400 assert!(display.contains("[PATH]"));
401 }
402
403 #[test]
404 fn test_sanitized_error_development_mode() {
405 let error = std::io::Error::new(
406 std::io::ErrorKind::NotFound,
407 "File not found: /etc/secrets/api_key.txt",
408 );
409
410 let sanitized = SanitizedError::development(error);
411 let display = format!("{}", sanitized);
412
413 assert!(display.contains("/etc/secrets/api_key.txt"));
415 }
416
417 #[test]
418 fn test_display_mode_default() {
419 assert_eq!(DisplayMode::default(), DisplayMode::Production);
421 }
422
423 #[test]
424 fn test_generic_error_message() {
425 let msg = generic_error();
426 assert_eq!(msg, GENERIC_ERROR_MESSAGE);
427 assert!(msg.contains("An error occurred"));
428 }
429
430 #[test]
431 fn test_no_false_positives() {
432 let message = "User 123 requested tool list";
434 assert_eq!(sanitize_error_message(message), message);
435
436 let message = "Server running on port 8080";
438 assert_eq!(sanitize_error_message(message), message);
439 }
440}