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