use thiserror::Error;
const SENSITIVE_PATTERNS: &[(&str, &str)] = &[
(
"(?i)(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}\\b",
"[AWS_ACCESS_KEY_ID]",
),
("[0-9a-zA-Z+/]{40}={0,2}\\b", "[AWS_SECRET_ACCESS_KEY]"),
(
"\\beyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\b",
"[JWT_TOKEN]",
),
("(?i)(postgres|postgresql)://[^@]+:[^@]+@", "$1://***:***@"),
("(?i)mysql://[^@]+:[^@]+@", "mysql://***:***@"),
("(?i)sqlite://[^?]*\\?[^&]*", "sqlite://***"),
(
"(?i)(api[_-]?key|access[_-]?key|secret[_-]?key)[\"']?\\s*[=:]\\s*[\"']?[a-zA-Z0-9_\\-]{20,}",
"$1=***REDACTED***",
),
(
"(?i)(bearer|authorization)\\s*:\\s*[a-zA-Z0-9_\\-\\.]+",
"$1: ***REDACTED***",
),
("/home/[a-zA-Z0-9_-]+/", "[USER_HOME_PATH]"),
("/etc/inklog/", "[CONFIG_PATH]"),
("/run/secrets/", "[SECRETS_PATH]"),
("(?i)(password|passwd|pwd)=[^&\\s]+", "$1=***"),
(
"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
"***@***.***",
),
("\\b1[3-9]\\d{9}\\b", "***-****-****"),
(
"\\b\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}\\b",
"****-****-****-****",
),
];
fn sanitize_message(msg: &str) -> String {
let mut result = msg.to_string();
for (pattern, replacement) in SENSITIVE_PATTERNS {
if let Ok(re) = regex::Regex::new(pattern) {
result = re.replace_all(&result, *replacement).to_string();
}
}
result
}
#[derive(Error, Debug)]
pub enum InklogError {
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Cache error: {0}")]
CacheError(String),
#[error("Encryption error: {0}")]
EncryptionError(String),
#[error("Shutdown error: {0}")]
Shutdown(String),
#[error("Channel error: {0}")]
ChannelError(String),
#[error("Compression error: {0}")]
CompressionError(String),
#[error("Runtime error: {0}")]
RuntimeError(String),
#[error("HTTP server error: {0}")]
HttpServerError(String),
#[error("Unknown error: {0}")]
Unknown(String),
}
impl From<toml::de::Error> for InklogError {
fn from(err: toml::de::Error) -> Self {
InklogError::ConfigError(err.to_string())
}
}
impl InklogError {
pub fn safe_message(&self) -> String {
match self {
InklogError::ConfigError(msg) => {
format!("Configuration error: {}", sanitize_message(msg))
}
InklogError::IoError(e) => {
format!("IO error: {}", sanitize_message(&e.to_string()))
}
InklogError::SerializationError(e) => {
format!("Serialization error: {}", sanitize_message(&e.to_string()))
}
InklogError::DatabaseError(msg) => {
format!("Database error: {}", sanitize_message(msg))
}
InklogError::CacheError(msg) => {
format!("Cache error: {}", sanitize_message(msg))
}
InklogError::EncryptionError(msg) => {
format!("Encryption error: {}", sanitize_message(msg))
}
InklogError::Shutdown(msg) => {
format!("Shutdown error: {}", sanitize_message(msg))
}
InklogError::ChannelError(msg) => {
format!("Channel error: {}", sanitize_message(msg))
}
InklogError::CompressionError(msg) => {
format!("Compression error: {}", sanitize_message(msg))
}
InklogError::RuntimeError(msg) => {
format!("Runtime error: {}", sanitize_message(msg))
}
InklogError::HttpServerError(msg) => {
format!("HTTP server error: {}", sanitize_message(msg))
}
InklogError::Unknown(msg) => {
format!("Unknown error: {}", sanitize_message(msg))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_message_redacts_aws_keys() {
let error = InklogError::ConfigError(
"Failed to load AKIAIOSFODNN7EXAMPLE from credentials".to_string(),
);
let msg = error.safe_message();
assert!(
msg.contains("[AWS_ACCESS_KEY_ID]") || msg.contains("***"),
"Message: {}",
msg
);
assert!(!msg.contains("AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn test_safe_message_redacts_jwt_tokens() {
let error = InklogError::ConfigError(
"prefix.eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.suffix".to_string(),
);
let msg = error.safe_message();
assert!(
msg.contains("[JWT_TOKEN]") || msg.contains("***"),
"Message: {}",
msg
);
}
#[test]
fn test_safe_message_redacts_database_urls() {
let error = InklogError::ConfigError(
"Connection failed: postgres://user:secret@localhost:5432/db".to_string(),
);
let msg = error.safe_message();
assert!(
msg.contains("***") || !msg.contains("secret"),
"Message: {}",
msg
);
}
#[test]
fn test_safe_message_redacts_user_paths() {
let error = InklogError::ConfigError(
"Config not found at /home/user/.config/inklog.yaml".to_string(),
);
let msg = error.safe_message();
assert!(
msg.contains("[USER_HOME_PATH]") || msg.contains("***"),
"Message: {}",
msg
);
assert!(!msg.contains("/home/user/"));
}
#[test]
fn test_safe_message_redacts_bearer_tokens() {
let error = InklogError::HttpServerError(
"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0".to_string(),
);
let msg = error.safe_message();
assert!(
msg.contains("REDACTED") || msg.contains("***"),
"Message: {}",
msg
);
}
#[test]
fn test_safe_message_preserves_non_sensitive() {
let error = InklogError::ConfigError("Configuration file not found".to_string());
let msg = error.safe_message();
assert!(msg.contains("Configuration file not found"));
}
#[test]
fn test_safe_message_redacts_passwords() {
let error =
InklogError::ConfigError("Failed to connect: password=mysecretpassword".to_string());
let msg = error.safe_message();
assert!(
!msg.contains("mysecretpassword") || msg.contains("***"),
"Message: {}",
msg
);
}
#[test]
fn test_safe_message_all_variants() {
assert!(
InklogError::ConfigError("x".into())
.safe_message()
.contains("Configuration error:")
);
assert!(
InklogError::DatabaseError("x".into())
.safe_message()
.contains("Database error:")
);
assert!(
InklogError::CacheError("x".into())
.safe_message()
.contains("Cache error:")
);
assert!(
InklogError::EncryptionError("x".into())
.safe_message()
.contains("Encryption error:")
);
assert!(
InklogError::Shutdown("x".into())
.safe_message()
.contains("Shutdown error:")
);
assert!(
InklogError::ChannelError("x".into())
.safe_message()
.contains("Channel error:")
);
assert!(
InklogError::CompressionError("x".into())
.safe_message()
.contains("Compression error:")
);
assert!(
InklogError::RuntimeError("x".into())
.safe_message()
.contains("Runtime error:")
);
assert!(
InklogError::Unknown("x".into())
.safe_message()
.contains("Unknown error:")
);
}
#[test]
fn test_safe_message_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let error = InklogError::IoError(io_err);
let msg = error.safe_message();
assert!(msg.contains("IO error:"));
assert!(msg.contains("file missing"));
}
#[test]
fn test_safe_message_serialization_error() {
let json_err = serde_json::from_str::<String>("invalid").unwrap_err();
let error = InklogError::SerializationError(json_err);
let msg = error.safe_message();
assert!(msg.contains("Serialization error:"));
}
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let inklog_err: InklogError = io_err.into();
assert!(matches!(inklog_err, InklogError::IoError(_)));
}
#[test]
fn test_from_serde_json_error() {
let json_err = serde_json::from_str::<i32>("not a number").unwrap_err();
let inklog_err: InklogError = json_err.into();
assert!(matches!(inklog_err, InklogError::SerializationError(_)));
}
#[test]
fn test_from_toml_de_error() {
let toml_err: toml::de::Error =
toml::from_str::<toml::Value>("invalid = = toml").unwrap_err();
let inklog_err: InklogError = toml_err.into();
assert!(matches!(inklog_err, InklogError::ConfigError(_)));
}
#[test]
fn test_safe_message_redacts_email() {
let error = InklogError::ConfigError("Contact admin@example.com for help".to_string());
let msg = error.safe_message();
assert!(!msg.contains("admin@example.com"));
}
#[test]
fn test_safe_message_redacts_phone() {
let error = InklogError::ConfigError("Call 13812345678 for support".to_string());
let msg = error.safe_message();
assert!(!msg.contains("13812345678"));
}
#[test]
fn test_safe_message_redacts_credit_card() {
let error = InklogError::ConfigError("Card: 4111111111111111".to_string());
let msg = error.safe_message();
assert!(!msg.contains("4111111111111111"));
}
#[test]
fn test_error_display_format() {
assert_eq!(
InklogError::ConfigError("test".into()).to_string(),
"Configuration error: test"
);
assert_eq!(
InklogError::ChannelError("closed".into()).to_string(),
"Channel error: closed"
);
}
}
pub type InklogResult<T> = std::result::Result<T, InklogError>;