use std::borrow::Cow;
use thiserror::Error;
const SENSITIVE_PATTERNS: &[&str] = &[
"/home/",
"/usr/",
"/tmp/",
"/var/",
"\\Users\\",
"C:\\",
"at line",
"panicked at",
"stack backtrace",
"thread '",
"src/",
".rs:",
"RUST_BACKTRACE",
"core::result::Result",
"std::io::Error",
"tokio::",
"hyper::",
"tonic::",
];
#[derive(Debug, Error)]
pub enum GrpcError {
#[error("Connection error: {0}")]
Connection(String),
#[error("Service error: {0}")]
Service(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Invalid argument: {0}")]
InvalidArgument(String),
#[error("Internal error: {0}")]
Internal(String),
}
pub type GrpcResult<T> = Result<T, GrpcError>;
impl From<tonic::Status> for GrpcError {
fn from(status: tonic::Status) -> Self {
match status.code() {
tonic::Code::NotFound => GrpcError::NotFound(status.message().to_string()),
tonic::Code::InvalidArgument => {
GrpcError::InvalidArgument(status.message().to_string())
}
tonic::Code::Unavailable => GrpcError::Connection(status.message().to_string()),
_ => GrpcError::Internal(status.message().to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct ErrorSanitizer {
debug_mode: bool,
}
impl ErrorSanitizer {
pub fn production() -> Self {
Self { debug_mode: false }
}
pub fn debug() -> Self {
Self { debug_mode: true }
}
pub fn is_debug(&self) -> bool {
self.debug_mode
}
pub fn to_status(&self, error: &GrpcError) -> tonic::Status {
match error {
GrpcError::NotFound(msg) => tonic::Status::not_found(msg),
GrpcError::InvalidArgument(msg) => tonic::Status::invalid_argument(msg),
GrpcError::Connection(msg) => {
if self.debug_mode {
tonic::Status::unavailable(msg)
} else {
tracing::error!(original_message = %msg, "gRPC connection error");
tonic::Status::unavailable("Service temporarily unavailable")
}
}
GrpcError::Service(msg) => {
if self.debug_mode {
tonic::Status::internal(msg)
} else {
tracing::error!(original_message = %msg, "gRPC service error");
tonic::Status::internal("Internal server error")
}
}
GrpcError::Internal(msg) => {
if self.debug_mode {
tonic::Status::internal(msg)
} else {
tracing::error!(original_message = %msg, "gRPC internal error");
tonic::Status::internal("Internal server error")
}
}
}
}
pub fn sanitize_message<'a>(&self, message: &'a str) -> Cow<'a, str> {
if self.debug_mode {
return Cow::Borrowed(message);
}
if contains_sensitive_pattern(message) {
tracing::warn!(
original_message = %message,
"Sanitized error message containing sensitive information"
);
Cow::Borrowed("Internal server error")
} else {
Cow::Borrowed(message)
}
}
}
impl Default for ErrorSanitizer {
fn default() -> Self {
Self::production()
}
}
fn contains_sensitive_pattern(message: &str) -> bool {
SENSITIVE_PATTERNS
.iter()
.any(|pattern| message.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn error_display_connection() {
let err = GrpcError::Connection("test error".to_string());
let display = err.to_string();
assert_eq!(display, "Connection error: test error");
}
#[rstest]
fn error_display_not_found() {
let err = GrpcError::NotFound("item".to_string());
let display = err.to_string();
assert_eq!(display, "Not found: item");
}
#[rstest]
fn from_tonic_status_not_found() {
let status = tonic::Status::not_found("User not found");
let error = GrpcError::from(status);
assert!(matches!(error, GrpcError::NotFound(ref msg) if msg == "User not found"));
}
#[rstest]
fn from_tonic_status_invalid_argument() {
let status = tonic::Status::invalid_argument("Invalid ID");
let error = GrpcError::from(status);
assert!(matches!(error, GrpcError::InvalidArgument(ref msg) if msg == "Invalid ID"));
}
#[rstest]
fn from_tonic_status_unavailable() {
let status = tonic::Status::unavailable("Service unavailable");
let error = GrpcError::from(status);
assert!(matches!(error, GrpcError::Connection(_)));
}
#[rstest]
fn sanitizer_production_mode_is_default() {
let sanitizer = ErrorSanitizer::default();
assert!(!sanitizer.is_debug());
}
#[rstest]
fn sanitizer_debug_mode() {
let sanitizer = ErrorSanitizer::debug();
assert!(sanitizer.is_debug());
}
#[rstest]
fn sanitizer_production_hides_internal_error() {
let sanitizer = ErrorSanitizer::production();
let error = GrpcError::Internal("panicked at src/main.rs:42".to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::Internal);
assert_eq!(status.message(), "Internal server error");
}
#[rstest]
fn sanitizer_production_hides_connection_error() {
let sanitizer = ErrorSanitizer::production();
let error = GrpcError::Connection("TCP error on /var/run/socket".to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::Unavailable);
assert_eq!(status.message(), "Service temporarily unavailable");
}
#[rstest]
fn sanitizer_production_hides_service_error() {
let sanitizer = ErrorSanitizer::production();
let error = GrpcError::Service("hyper::Error: connection reset".to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::Internal);
assert_eq!(status.message(), "Internal server error");
}
#[rstest]
fn sanitizer_production_preserves_not_found() {
let sanitizer = ErrorSanitizer::production();
let error = GrpcError::NotFound("User with ID 123 not found".to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::NotFound);
assert_eq!(status.message(), "User with ID 123 not found");
}
#[rstest]
fn sanitizer_production_preserves_invalid_argument() {
let sanitizer = ErrorSanitizer::production();
let error = GrpcError::InvalidArgument("Field 'name' is required".to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert_eq!(status.message(), "Field 'name' is required");
}
#[rstest]
fn sanitizer_debug_preserves_all_details() {
let sanitizer = ErrorSanitizer::debug();
let original_msg = "panicked at src/main.rs:42: stack backtrace";
let error = GrpcError::Internal(original_msg.to_string());
let status = sanitizer.to_status(&error);
assert_eq!(status.code(), tonic::Code::Internal);
assert_eq!(status.message(), original_msg);
}
#[rstest]
#[case("/home/user/.config/secret.key", true)]
#[case("/usr/local/bin/service", true)]
#[case("/tmp/crash-dump-12345", true)]
#[case("panicked at src/handler.rs:99", true)]
#[case("stack backtrace:", true)]
#[case("core::result::Result<T, E>", true)]
#[case("hyper::Error: broken pipe", true)]
#[case("User not found", false)]
#[case("Invalid argument: name is required", false)]
#[case("Query timeout exceeded", false)]
fn contains_sensitive_pattern_detection(#[case] message: &str, #[case] expected: bool) {
let result = contains_sensitive_pattern(message);
assert_eq!(result, expected, "Pattern detection failed for: {message}");
}
#[rstest]
fn sanitize_message_production_removes_paths() {
let sanitizer = ErrorSanitizer::production();
let result = sanitizer.sanitize_message("Error at /home/user/app/src/main.rs:42");
assert_eq!(result, "Internal server error");
}
#[rstest]
fn sanitize_message_production_preserves_safe_messages() {
let sanitizer = ErrorSanitizer::production();
let result = sanitizer.sanitize_message("User not found");
assert_eq!(result, "User not found");
}
#[rstest]
fn sanitize_message_debug_preserves_all() {
let sanitizer = ErrorSanitizer::debug();
let result = sanitizer.sanitize_message("Error at /home/user/app/src/main.rs:42");
assert_eq!(result, "Error at /home/user/app/src/main.rs:42");
}
#[rstest]
fn sanitizer_clone_preserves_mode() {
let sanitizer = ErrorSanitizer::debug();
let cloned = sanitizer.clone();
assert_eq!(cloned.is_debug(), sanitizer.is_debug());
}
}