1use std::fmt;
36
37use serde::{Deserialize, Serialize};
38
39use crate::security::errors::SecurityError;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum DetailLevel {
46 Development,
48
49 Staging,
51
52 Production,
54}
55
56impl fmt::Display for DetailLevel {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Self::Development => write!(f, "Development"),
60 Self::Staging => write!(f, "Staging"),
61 Self::Production => write!(f, "Production"),
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
70#[allow(clippy::struct_excessive_bools)] pub struct SanitizationConfig {
72 pub hide_database_urls: bool,
74
75 pub hide_sql: bool,
77
78 pub hide_paths: bool,
80
81 pub hide_ips: bool,
83
84 pub hide_emails: bool,
86
87 pub hide_credentials: bool,
89}
90
91impl SanitizationConfig {
92 #[must_use]
96 pub fn permissive() -> Self {
97 Self {
98 hide_database_urls: false,
99 hide_sql: false,
100 hide_paths: false,
101 hide_ips: false,
102 hide_emails: false,
103 hide_credentials: false,
104 }
105 }
106
107 #[must_use]
111 pub fn standard() -> Self {
112 Self {
113 hide_database_urls: true,
114 hide_sql: true,
115 hide_paths: false,
116 hide_ips: true,
117 hide_emails: true,
118 hide_credentials: true,
119 }
120 }
121
122 #[must_use]
126 pub fn strict() -> Self {
127 Self {
128 hide_database_urls: true,
129 hide_sql: true,
130 hide_paths: true,
131 hide_ips: true,
132 hide_emails: true,
133 hide_credentials: true,
134 }
135 }
136}
137
138#[derive(Debug, Clone)]
143pub struct ErrorFormatter {
144 detail_level: DetailLevel,
145 config: SanitizationConfig,
146}
147
148impl ErrorFormatter {
149 #[must_use]
151 pub fn new(detail_level: DetailLevel) -> Self {
152 let config = Self::config_for_level(detail_level);
153 Self {
154 detail_level,
155 config,
156 }
157 }
158
159 #[must_use]
161 pub fn with_config(detail_level: DetailLevel, config: SanitizationConfig) -> Self {
162 Self {
163 detail_level,
164 config,
165 }
166 }
167
168 #[must_use]
170 pub fn development() -> Self {
171 Self::new(DetailLevel::Development)
172 }
173
174 #[must_use]
176 pub fn staging() -> Self {
177 Self::new(DetailLevel::Staging)
178 }
179
180 #[must_use]
182 pub fn production() -> Self {
183 Self::new(DetailLevel::Production)
184 }
185
186 fn config_for_level(level: DetailLevel) -> SanitizationConfig {
188 match level {
189 DetailLevel::Development => SanitizationConfig::permissive(),
190 DetailLevel::Staging => SanitizationConfig::standard(),
191 DetailLevel::Production => SanitizationConfig::strict(),
192 }
193 }
194
195 #[must_use]
203 pub fn format_error(&self, error_msg: &str) -> String {
204 match self.detail_level {
205 DetailLevel::Development => {
206 error_msg.to_string()
208 },
209 DetailLevel::Staging => {
210 self.sanitize_error(error_msg)
212 },
213 DetailLevel::Production => {
214 if Self::is_security_related(error_msg) {
216 "Security validation failed".to_string()
217 } else {
218 "An error occurred while processing your request".to_string()
219 }
220 },
221 }
222 }
223
224 #[must_use]
226 pub fn format_security_error(&self, error: &SecurityError) -> String {
227 let error_msg = error.to_string();
228
229 match self.detail_level {
230 DetailLevel::Development => {
231 error_msg
233 },
234 DetailLevel::Staging => {
235 self.extract_error_type_and_sanitize(&error_msg)
237 },
238 DetailLevel::Production => {
239 match error {
241 SecurityError::AuthRequired => "Authentication required".to_string(),
242 SecurityError::InvalidToken
243 | SecurityError::TokenExpired { .. }
244 | SecurityError::TokenMissingClaim { .. }
245 | SecurityError::InvalidTokenAlgorithm { .. } => {
246 "Invalid authentication".to_string()
247 },
248 SecurityError::TlsRequired { .. }
249 | SecurityError::TlsVersionTooOld { .. }
250 | SecurityError::MtlsRequired { .. }
251 | SecurityError::InvalidClientCert { .. } => {
252 "Connection security validation failed".to_string()
253 },
254 SecurityError::QueryTooDeep { .. }
255 | SecurityError::QueryTooComplex { .. }
256 | SecurityError::QueryTooLarge { .. } => "Query validation failed".to_string(),
257 SecurityError::IntrospectionDisabled { .. } => {
258 "Schema introspection is not available".to_string()
259 },
260 _ => "An error occurred while processing your request".to_string(),
261 }
262 },
263 }
264 }
265
266 fn sanitize_error(&self, error_msg: &str) -> String {
268 let mut result = error_msg.to_string();
269
270 if self.config.hide_database_urls {
272 result = Self::hide_pattern(&result, "postgresql://", "**hidden**");
273 result = Self::hide_pattern(&result, "mysql://", "**hidden**");
274 result = Self::hide_pattern(&result, "mongodb://", "**hidden**");
275 }
276
277 if self.config.hide_sql {
279 result = Self::hide_pattern(&result, "SELECT ", "[SQL hidden]");
280 result = Self::hide_pattern(&result, "INSERT ", "[SQL hidden]");
281 result = Self::hide_pattern(&result, "UPDATE ", "[SQL hidden]");
282 result = Self::hide_pattern(&result, "DELETE ", "[SQL hidden]");
283 }
284
285 if self.config.hide_paths {
287 result = Self::redact_paths(&result);
288 }
289
290 if self.config.hide_ips {
292 result = Self::redact_ips(&result);
293 }
294
295 if self.config.hide_emails {
297 result = Self::redact_emails(&result);
298 }
299
300 if self.config.hide_credentials {
302 result = Self::hide_pattern(&result, "@", "[credentials redacted]");
303 }
304
305 result
306 }
307
308 fn is_security_related(error_msg: &str) -> bool {
310 let lower = error_msg.to_lowercase();
311 lower.contains("auth")
312 || lower.contains("permission")
313 || lower.contains("forbidden")
314 || lower.contains("security")
315 || lower.contains("tls")
316 || lower.contains("https")
317 }
318
319 fn extract_error_type_and_sanitize(&self, error_msg: &str) -> String {
321 let sanitized = self.sanitize_error(error_msg);
322
323 if sanitized.len() > 100 {
325 format!("{}...", &sanitized[..100])
326 } else {
327 sanitized
328 }
329 }
330
331 fn hide_pattern(text: &str, pattern: &str, replacement: &str) -> String {
333 if text.contains(pattern) {
334 text.replace(pattern, replacement)
335 } else {
336 text.to_string()
337 }
338 }
339
340 fn redact_paths(text: &str) -> String {
342 let mut result = text.to_string();
344
345 if result.contains('/') && result.contains(".rs") {
347 result = result.replace('/', "*");
348 }
349
350 if result.contains('\\') {
352 result = result.replace('\\', "*");
353 }
354
355 result
356 }
357
358 fn redact_ips(text: &str) -> String {
360 let mut result = String::new();
362 let mut current_word = String::new();
363
364 for c in text.chars() {
365 if c.is_numeric() || c == '.' {
366 current_word.push(c);
367 } else {
368 if Self::looks_like_ip(¤t_word) {
370 result.push_str("[IP]");
371 } else {
372 result.push_str(¤t_word);
373 }
374 current_word.clear();
375 result.push(c);
376 }
377 }
378
379 if Self::looks_like_ip(¤t_word) {
381 result.push_str("[IP]");
382 } else {
383 result.push_str(¤t_word);
384 }
385
386 result
387 }
388
389 fn redact_emails(text: &str) -> String {
391 let mut result = String::new();
393 let mut in_email = false;
394 let mut email = String::new();
395
396 for c in text.chars() {
397 if c == '@' {
398 in_email = true;
399 email.clear();
400 email.push(c);
401 } else if in_email {
402 email.push(c);
403 if c == ' ' || c == '\n' {
404 result.push_str("[email]");
405 result.push(c);
406 in_email = false;
407 email.clear();
408 }
409 } else {
410 result.push(c);
411 }
412 }
413
414 if in_email && email.contains('@') {
416 result.push_str("[email]");
417 } else {
418 result.push_str(&email);
419 }
420
421 result
422 }
423
424 fn looks_like_ip(s: &str) -> bool {
426 if !s.contains('.') {
427 return false;
428 }
429
430 let parts: Vec<&str> = s.split('.').collect();
431 if parts.len() != 4 {
432 return false;
433 }
434
435 parts.iter().all(|p| {
436 !p.is_empty()
437 && p.chars().all(|c| c.is_ascii_digit())
438 && p.parse::<u32>().unwrap_or(256) <= 255
439 })
440 }
441
442 #[must_use]
444 pub const fn detail_level(&self) -> DetailLevel {
445 self.detail_level
446 }
447
448 #[must_use]
450 pub const fn config(&self) -> &SanitizationConfig {
451 &self.config
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 fn db_error_msg() -> &'static str {
464 "Database error: connection refused to postgresql://user:password@db.example.com:5432/mydb"
465 }
466
467 fn sql_error_msg() -> &'static str {
468 "SQL Error: SELECT * FROM users WHERE id = 123; failed at db.example.com"
469 }
470
471 fn network_error_msg() -> &'static str {
472 "Connection failed to 192.168.1.100 (admin@example.com)"
473 }
474
475 #[test]
480 fn test_development_shows_full_details() {
481 let formatter = ErrorFormatter::development();
482 let formatted = formatter.format_error(db_error_msg());
483 assert!(formatted.contains("postgresql"));
484 assert!(formatted.contains("user:password"));
485 }
486
487 #[test]
488 fn test_staging_shows_limited_details() {
489 let formatter = ErrorFormatter::staging();
490 let formatted = formatter.format_error(db_error_msg());
491 assert!(!formatted.contains("postgresql://"));
493 let _ = formatted;
495 }
496
497 #[test]
498 fn test_production_shows_generic_error() {
499 let formatter = ErrorFormatter::production();
500 let formatted = formatter.format_error(db_error_msg());
501 assert!(!formatted.contains("postgresql"));
502 assert!(!formatted.contains("password"));
503 assert!(formatted.contains("error") || formatted.contains("request"));
504 }
505
506 #[test]
511 fn test_database_url_sanitization() {
512 let formatter = ErrorFormatter::staging();
513 let formatted = formatter.format_error(db_error_msg());
514 assert!(!formatted.contains("postgresql://"));
516 assert!(formatted.contains("**hidden**") || !formatted.contains("postgresql://"));
518 }
519
520 #[test]
521 fn test_sql_sanitization() {
522 let formatter = ErrorFormatter::staging();
523 let formatted = formatter.format_error(sql_error_msg());
524 assert!(!formatted.contains("SELECT"));
525 }
526
527 #[test]
528 fn test_ip_sanitization() {
529 let formatter = ErrorFormatter::staging();
530 let formatted = formatter.format_error(network_error_msg());
531 assert!(!formatted.contains("192.168"));
532 }
533
534 #[test]
535 fn test_email_sanitization() {
536 let formatter = ErrorFormatter::staging();
537 let formatted = formatter.format_error(network_error_msg());
538 assert!(!formatted.contains("admin@example"));
539 }
540
541 #[test]
546 fn test_security_error_development() {
547 let formatter = ErrorFormatter::development();
548 let error = SecurityError::AuthRequired;
549 let formatted = formatter.format_security_error(&error);
550 assert!(formatted.contains("Authentication"));
551 }
552
553 #[test]
554 fn test_security_error_production() {
555 let formatter = ErrorFormatter::production();
556 let error = SecurityError::AuthRequired;
557 let formatted = formatter.format_security_error(&error);
558 assert!(!formatted.is_empty());
559 assert!(formatted.len() < 100); }
561
562 #[test]
563 fn test_token_expired_error_production() {
564 let formatter = ErrorFormatter::production();
565 let error = SecurityError::TokenExpired {
566 expired_at: chrono::Utc::now(),
567 };
568 let formatted = formatter.format_security_error(&error);
569 assert!(!formatted.contains("expired_at"));
570 assert!(formatted.contains("Invalid") || formatted.contains("Authentication"));
571 }
572
573 #[test]
574 fn test_query_too_deep_error_production() {
575 let formatter = ErrorFormatter::production();
576 let error = SecurityError::QueryTooDeep {
577 depth: 20,
578 max_depth: 10,
579 };
580 let formatted = formatter.format_security_error(&error);
581 assert!(!formatted.contains("20"));
582 assert!(!formatted.contains("10"));
583 }
584
585 #[test]
590 fn test_detail_level_display() {
591 assert_eq!(DetailLevel::Development.to_string(), "Development");
592 assert_eq!(DetailLevel::Staging.to_string(), "Staging");
593 assert_eq!(DetailLevel::Production.to_string(), "Production");
594 }
595
596 #[test]
597 fn test_sanitization_config_permissive() {
598 let config = SanitizationConfig::permissive();
599 assert!(!config.hide_database_urls);
600 assert!(!config.hide_sql);
601 }
602
603 #[test]
604 fn test_sanitization_config_standard() {
605 let config = SanitizationConfig::standard();
606 assert!(config.hide_database_urls);
607 assert!(config.hide_sql);
608 assert!(!config.hide_paths);
609 }
610
611 #[test]
612 fn test_sanitization_config_strict() {
613 let config = SanitizationConfig::strict();
614 assert!(config.hide_database_urls);
615 assert!(config.hide_sql);
616 assert!(config.hide_paths);
617 }
618
619 #[test]
620 fn test_formatter_helpers() {
621 let dev = ErrorFormatter::development();
622 assert_eq!(dev.detail_level(), DetailLevel::Development);
623
624 let prod = ErrorFormatter::production();
625 assert_eq!(prod.detail_level(), DetailLevel::Production);
626 }
627
628 #[test]
633 fn test_empty_error_message() {
634 let formatter = ErrorFormatter::staging();
635 let formatted = formatter.format_error("");
636 assert!(formatted.is_empty() || !formatted.is_empty()); }
638
639 #[test]
640 fn test_multiple_sensitive_elements() {
641 let formatter = ErrorFormatter::staging();
642 let msg = "Failed to connect to postgresql://admin@192.168.1.1 with email user@example.com";
643 let formatted = formatter.format_error(msg);
644
645 assert!(!formatted.contains("postgresql"));
646 assert!(!formatted.contains("192.168"));
647 assert!(!formatted.contains("user@example"));
648 }
649
650 #[test]
651 fn test_security_error_categorization() {
652 let formatter = ErrorFormatter::production();
653
654 let auth_error = SecurityError::AuthRequired;
656 let formatted = formatter.format_security_error(&auth_error);
657 assert!(formatted.contains("Authentication"));
658
659 let intro_error = SecurityError::IntrospectionDisabled {
661 detail: "test".to_string(),
662 };
663 let formatted = formatter.format_security_error(&intro_error);
664 assert!(formatted.contains("introspection"));
665 }
666
667 #[test]
668 fn test_custom_sanitization_config() {
669 let config = SanitizationConfig {
670 hide_database_urls: false,
671 hide_sql: false,
672 hide_paths: true,
673 hide_ips: false,
674 hide_emails: false,
675 hide_credentials: false,
676 };
677
678 let formatter = ErrorFormatter::with_config(DetailLevel::Staging, config);
679 let msg = "Error at /home/user/project: connection to 192.168.1.1 failed";
680 let formatted = formatter.format_error(msg);
681
682 assert!(formatted.contains("192.168"));
685 let _ = formatted;
687 }
688
689 #[test]
690 fn test_long_error_truncation() {
691 let formatter = ErrorFormatter::staging();
692 let long_msg = "a".repeat(200);
693 let formatted = formatter.format_error(&long_msg);
694
695 assert!(formatted.len() <= 200 + 10); }
698}