use crate::limits::LimitExceeded;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
#[error("parse error{}: {message}", if *line > 0 { format!(" at line {}, column {}", line, column) } else { String::new() })]
Parse {
message: String,
line: usize,
column: usize,
},
#[error("execution error: {0}")]
Execution(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("resource limit exceeded: {0}")]
ResourceLimit(#[from] LimitExceeded),
#[error("network error: {0}")]
Network(String),
#[error("regex error: {0}")]
Regex(#[from] regex::Error),
#[error("execution cancelled")]
Cancelled,
#[error("internal error: {0}")]
Internal(String),
}
impl Error {
pub fn parse_at(message: impl Into<String>, line: usize, column: usize) -> Self {
Self::Parse {
message: message.into(),
line,
column,
}
}
pub fn parse(message: impl Into<String>) -> Self {
Self::Parse {
message: message.into(),
line: 0,
column: 0,
}
}
pub fn io_sanitized(err: std::io::Error) -> Self {
Self::Io(std::io::Error::new(
err.kind(),
sanitize_error_message(&err.to_string()),
))
}
pub fn network_sanitized(context: &str, err: &dyn std::fmt::Display) -> Self {
Self::Network(format!(
"{}: {}",
context,
sanitize_error_message(&err.to_string())
))
}
}
fn sanitize_error_message(msg: &str) -> String {
use std::sync::LazyLock;
static PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r#"(/(?:home|usr|var|etc|opt|root|proc|sys|run|snap|nix|mnt|media)[/][^\s:"']+)"#,
)
.expect("path regex")
});
static IPV4_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b").expect("ipv4 regex")
});
static IPV6_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\[?[0-9a-fA-F:]{3,39}\]?(:\d+)?").expect("ipv6 regex")
});
static TLS_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)(ssl|tls)\s*(handshake|negotiation|error|alert)[^.;]*[.;]?")
.expect("tls regex")
});
let mut result = msg.to_string();
result = PATH_RE.replace_all(&result, "<path>").to_string();
result = IPV4_RE.replace_all(&result, "<address>").to_string();
if result.contains("::") {
result = IPV6_RE.replace_all(&result, "<address>").to_string();
}
result = TLS_RE.replace_all(&result, "<tls-error>").to_string();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_host_paths() {
let msg = "No such file: /home/user/.config/bashkit/settings.json";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("/home/user"));
assert!(sanitized.contains("<path>"));
}
#[test]
fn sanitize_strips_ipv4() {
let msg = "connection refused: 192.168.1.100:8080";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("192.168"));
assert!(sanitized.contains("<address>"));
}
#[test]
fn sanitize_strips_tls_details() {
let msg = "SSL handshake failed with cipher TLS_AES_256_GCM;";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("cipher"));
assert!(sanitized.contains("<tls-error>"));
}
#[test]
fn sanitize_preserves_safe_paths() {
let msg = "file not found: /tmp/script.sh";
let sanitized = sanitize_error_message(msg);
assert!(sanitized.contains("/tmp/script.sh"));
}
#[test]
fn sanitize_preserves_generic_messages() {
let msg = "operation timed out";
assert_eq!(sanitize_error_message(msg), msg);
}
}