Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/ERROR_HANDLING_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ match service.create_product(request).await {
## Files Modified/Created

### Modified
- `backend/src/error.rs` - Enhanced error types and sanitization
- `backend/src/error/` - Enhanced error types and sanitization
- `backend/src/middleware.rs` - Added error_handler module
- `backend/src/main.rs` - Integrated error monitoring and middleware

Expand Down
51 changes: 51 additions & 0 deletions backend/src/error/code.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};

/// Standardized error codes for programmatic handling
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
// Authentication & Authorization (1000-1099)
Unauthorized = 1000,
InvalidCredentials = 1001,
TokenExpired = 1002,
TokenInvalid = 1003,
InsufficientPermissions = 1004,

// Validation Errors (1100-1199)
ValidationFailed = 1100,
InvalidInput = 1101,
MissingRequiredField = 1102,
InvalidFormat = 1103,
ValueOutOfRange = 1104,

// Resource Errors (1200-1299)
ResourceNotFound = 1200,
ResourceAlreadyExists = 1201,
ResourceConflict = 1202,
ResourceDeleted = 1203,

// Rate Limiting (1300-1399)
RateLimitExceeded = 1300,
QuotaExceeded = 1301,

// Database Errors (1400-1499)
DatabaseError = 1400,
DatabaseConnectionFailed = 1401,
DatabaseQueryFailed = 1402,
DatabaseConstraintViolation = 1403,

// External Service Errors (1500-1599)
ExternalServiceError = 1500,
BlockchainError = 1501,
PaymentServiceError = 1502,

// Internal Errors (1600-1699)
InternalServerError = 1600,
ConfigurationError = 1601,
CryptographyError = 1602,

// Business Logic Errors (1700-1799)
BusinessRuleViolation = 1700,
InvalidStateTransition = 1701,
OperationNotAllowed = 1702,
}
76 changes: 76 additions & 0 deletions backend/src/error/conversions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use super::AppError;

impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
tracing::error!(error = ?err, "Database error");
AppError::Database(err)
}
}

impl From<serde_json::Error> for AppError {
fn from(err: serde_json::Error) -> Self {
tracing::debug!(error = %err, "JSON parsing error");
AppError::BadRequest("Invalid JSON format".to_string())
}
}

impl From<bcrypt::BcryptError> for AppError {
fn from(err: bcrypt::BcryptError) -> Self {
tracing::error!(error = ?err, "Password hashing error");
AppError::Cryptography("Password operation failed".to_string())
}
}

impl From<chrono::ParseError> for AppError {
fn from(err: chrono::ParseError) -> Self {
tracing::debug!(error = %err, "Date parsing error");
AppError::Validation("Invalid date format".to_string())
}
}

impl From<std::net::AddrParseError> for AppError {
fn from(err: std::net::AddrParseError) -> Self {
tracing::debug!(error = %err, "Address parsing error");
AppError::Validation("Invalid network address".to_string())
}
}

impl From<config::ConfigError> for AppError {
fn from(err: config::ConfigError) -> Self {
tracing::error!(error = ?err, "Configuration error");
AppError::Configuration("Configuration error".to_string())
}
}

impl From<jsonwebtoken::errors::Error> for AppError {
fn from(err: jsonwebtoken::errors::Error) -> Self {
use jsonwebtoken::errors::ErrorKind;

tracing::debug!(error = ?err, "JWT error");

match err.kind() {
ErrorKind::ExpiredSignature => AppError::TokenExpired,
ErrorKind::InvalidToken
| ErrorKind::InvalidSignature
| ErrorKind::InvalidAlgorithm
| ErrorKind::Base64(_)
| ErrorKind::Json(_)
| ErrorKind::Utf8(_) => AppError::TokenInvalid,
_ => AppError::Unauthorized,
}
}
}

impl From<uuid::Error> for AppError {
fn from(err: uuid::Error) -> Self {
tracing::debug!(error = %err, "UUID parsing error");
AppError::Validation("Invalid ID format".to_string())
}
}

impl From<redis::RedisError> for AppError {
fn from(err: redis::RedisError) -> Self {
tracing::error!(error = ?err, "Redis error");
AppError::Internal("Cache service error".to_string())
}
}
9 changes: 9 additions & 0 deletions backend/src/error/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
pub mod code;
pub mod conversions;
pub mod response;
pub mod sanitize;
pub mod types;

pub use code::ErrorCode;
pub use response::ErrorResponse;
pub use types::AppError;
72 changes: 72 additions & 0 deletions backend/src/error/response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::code::ErrorCode;

/// Standardized error response structure
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
/// HTTP status code
pub status: u16,

/// Standardized error code for programmatic handling
pub code: ErrorCode,

/// User-friendly error message (sanitized)
pub message: String,

/// Unique correlation ID for tracking and debugging
pub correlation_id: String,

/// Optional additional details (only in development)
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}

impl ErrorResponse {
/// Create a new error response with correlation ID
pub fn new(status: StatusCode, code: ErrorCode, message: String) -> Self {
Self {
status: status.as_u16(),
code,
message,
correlation_id: Uuid::new_v4().to_string(),
details: None,
}
}

/// Add details (only included in development mode)
pub fn with_details(mut self, details: String) -> Self {
if cfg!(debug_assertions) {
self.details = Some(details);
}
self
}
}

#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;

#[test]
fn test_error_response_excludes_details_in_release() {
let response = ErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::InternalServerError,
"Test error".to_string(),
)
.with_details("Sensitive internal details".to_string());

#[cfg(not(debug_assertions))]
assert!(response.details.is_none());

#[cfg(debug_assertions)]
assert!(response.details.is_some());
}
}
70 changes: 70 additions & 0 deletions backend/src/error/sanitize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use regex::Regex;

/// Sanitize database errors to prevent information disclosure
pub fn sanitize_database_error(err: &sqlx::Error) -> String {
match err {
sqlx::Error::RowNotFound => "Record not found".to_string(),
sqlx::Error::ColumnNotFound(_) => "Database schema error".to_string(),
sqlx::Error::Database(_) => "Database constraint violation".to_string(),
sqlx::Error::PoolTimedOut => "Database connection timeout".to_string(),
sqlx::Error::PoolClosed => "Database connection closed".to_string(),
_ => "Database operation failed".to_string(),
}
}

/// Sanitize user messages to prevent information disclosure
pub fn sanitize_message(msg: &str) -> String {
let msg = Regex::new(r"(/[a-zA-Z0-9_\-./]+)")
.unwrap()
.replace_all(msg, "[path]");

let msg = Regex::new(r"(?i)(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN)")
.unwrap()
.replace_all(&msg, "[sql]");

let msg = Regex::new(r"(postgres|mysql|mongodb)://[^\s]+")
.unwrap()
.replace_all(&msg, "[connection]");

let msg = Regex::new(r"([a-zA-Z0-9_-]{32,})")
.unwrap()
.replace_all(&msg, "[token]");

msg.to_string()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_sanitize_message_removes_paths() {
let msg = "Error in /usr/local/app/src/main.rs";
let sanitized = sanitize_message(msg);
assert!(sanitized.contains("[path]"));
assert!(!sanitized.contains("/usr/local"));
}

#[test]
fn test_sanitize_message_removes_sql() {
let msg = "Error in SELECT * FROM users WHERE id = 1";
let sanitized = sanitize_message(msg);
assert!(sanitized.contains("[sql]"));
assert!(!sanitized.contains("SELECT"));
}

#[test]
fn test_sanitize_message_removes_connection_strings() {
let msg = "Failed to connect to postgres://user:pass@localhost:5432/db";
let sanitized = sanitize_message(msg);
assert!(sanitized.contains("[connection]"));
assert!(!sanitized.contains("postgres://"));
}

#[test]
fn test_sanitize_message_removes_tokens() {
let msg = "Invalid token: abc123def456ghi789jkl012mno345pqr678";
let sanitized = sanitize_message(msg);
assert!(sanitized.contains("[token]"));
}
}
Loading